Skip to content

feat(babel)!: add parallel processing via worker threads#1956

Open
davidtaylorhq wants to merge 2 commits intorollup:masterfrom
davidtaylorhq:babel-parallel
Open

feat(babel)!: add parallel processing via worker threads#1956
davidtaylorhq wants to merge 2 commits intorollup:masterfrom
davidtaylorhq:babel-parallel

Conversation

@davidtaylorhq
Copy link

@davidtaylorhq davidtaylorhq commented Jan 12, 2026

Rollup Plugin Name: @rollup/plugin-babel

This PR contains:

  • bugfix
  • feature
  • refactor
  • documentation
  • other

Are tests included?

  • yes (bugfixes and features will not be merged without tests)
  • no

Breaking Changes?

  • yes (breaking changes will not be merged unless absolutely necessary)
  • no

If yes, then include "BREAKING CHANGES:" in the first commit message body, followed by a description of what is breaking.

List any relevant issue numbers: n/a

Description

feat(babel): add parallel processing via worker threads

Add a parallel option that processes files concurrently using Node.js worker threads. This reduces build times for large projects where Babel transformation is a bottleneck. This is similar to the existing parallel behavior of @rollup/plugin-terser.

This required some fairly significant refactoring, because we can only pass serializable objects between the main thread and the worker threads. It also required changes to the plugin's own build config, so that we can generate a dedicated worker entrypoint.

Validations are added to ensure that unserializable config (e.g. inline babel plugins) cannot be used alongside the new parallel mode. For people using dedicated babel config files, this isn't a problem, because they are loaded directly by babel in the worker thread itself.

The worker threads do have a setup cost, so this only makes sense for large projects. In Discourse, enabling this parallel mode cuts our overall vite (rolldown) build time by about 45% (from ~11s to ~6s) on my machine.


This PR is based on #1954, which should ideally be merged first. Happy to rebase this one on main if the other PR isn't mergable.

For testing, I've pushed a built version of this branch to https://github.com/davidtaylorhq/plugins/tree/babel-parallel-built. In pnpm, it can be installed like:

"@rollup/plugin-babel": "davidtaylorhq/rollup-plugins#babel-parallel-built&path:/packages/babel",

- drops support for rollup 1.x, which allows simplifying the implementation

- implements 'plugin hook filters' for improved performance under rolldown

- passes a new `code` argument to custom filter functions, so that consumers can add more advanced filtering logic

- allows custom filter functions to be async
Add a `parallel` option that processes files concurrently using Node.js worker threads. This reduces build times for large projects where Babel transformation is a bottleneck. This is similar to the existing parallel behavior of `@rollup/plugin-terser`.

This required some fairly significant refactoring, because we can only pass serializable objects between the main thread and the worker threads. It also required changes to the plugin's own build config, so that we can generate a dedicated worker entrypoint.

Validations are added to ensure that unserializable config (e.g. inline babel plugins) cannot be used alongside the new parallel mode. For people using dedicated babel config files, this isn't a problem, because they are loaded directly by babel in the worker thread itself.

The worker threads do have a setup cost, so this only makes sense for large projects. In Discourse, enabling this parallel mode cuts our overall vite (rolldown) build time by about 45% (from ~11s to ~6s) on my machine.
@davidtaylorhq
Copy link
Author

davidtaylorhq commented Feb 5, 2026

@Andarist @shellscape sorry for the ping, but just wanted to check if this is something you'd consider looking at? The performance benefits are pretty massive for large projects using Babel.

@NullVoxPopuli
Copy link

I'm really looking forward to this landing! I have a local tarball that I've been copying into my projects to have the benefits of this PR - and it's really good. I can no longer imagine using babel in rollup/vite/rolldown without the parallel mode <3

(usage without parallel mode is painfully slow)


const parallelWorkerCount = typeof parallel === 'number' ? parallel : 4;
workerPool = new WorkerPool(
new URL('./worker.js', import.meta.url).pathname,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this compatible with repo-wide supported node.js version?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would ci tell us?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worker Threads and import.meta.url are both supported in Node 14 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have you considered using some existing worker pool libraries?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I aimed to match the implementation of the existing Terser plugin, since I wasn't sure if adding new dependencies would be welcome. I'm certainly happy to explore it though! Did you have any particular libraries in mind?

workerpool looks like it could be a good bet 👀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's fine to keep it as-is if it matches the one in the terser plugin. It would be nice to extract this to a shared package but I don't know what's the proper drill for this. It probably could/should be extracted once this PR lands.

@shellscape
Copy link
Collaborator

@CharlieHelps please do a thorough review of this PR to determine if it's ready to merge. Look at the diff, reviews, comments, and referenced issues/docs/links to build a complete understanding. Then respond with a comment that includes a list of MUST fix items that must be fixed before the the PR is merged, and a list of IMPROVEMENTS that shouldn't block the merge but may be quick improvements or things that could be addressed in a follow up PR. The MUST fix items should explain exactly what's wrong and what needs to change to resolve the problem. Both should be numbered lists and the IMPROVEMENTS list should continue counting up from the MUST list. It's OK if there are no IMPROVEMENTS or MUST fix items, you can just omit the lists.

@shellscape
Copy link
Collaborator

@davidtaylorhq looks like Github's buggy UX bit us on this and I can't see/approve the CI runs. please push a new commit to the branch to trigger that UX again.

@NullVoxPopuli
Copy link

@shellscape you can close and re-open a PR to trigger that, usually -- no reason to make a contributor do a silly because GH is buggy <3

@charliecreates
Copy link
Contributor

I pulled the branch and reviewed the diff + existing review comments. Local verification for @rollup/plugin-babel (build, test, test:ts) passed; only a no-console eslint warning is present in workerPool.terminate().

MUST fix

  1. Windows/URL-safe worker entry resolution: In packages/babel/src/index.js the worker path is built via new URL('./worker.js', import.meta.url).pathname. .pathname can be Windows-unsafe (e.g. leading /C:/...) and can be URL-encoded. Please switch to either passing the URL directly (new URL('./worker.js', import.meta.url)) or fileURLToPath(new URL('./worker.js', import.meta.url)), and keep WorkerPool compatible.
  2. Fix filter typing: packages/babel/types/index.d.ts currently declares filter?: (id: string, code: string) => Promise<boolean>; but the implementation supports sync filters too. Change this to boolean | Promise<boolean> so TS users aren’t forced async.
  3. Validate numeric parallel values: const parallelWorkerCount = typeof parallel === 'number' ? parallel : 4; accepts 0, negatives, NaN, and floats. Some of these can yield a pool that never creates workers and transforms that never resolve. Please guard with something like Number.isInteger(parallel) && parallel > 0 and throw a clear error otherwise.
  4. Confirm the Rollup support change is intentional: This PR’s commits drop Rollup ^1.20.0 from peerDependencies (now ^2 || ^3 || ^4). If that’s intentional, it should be explicitly called out (and semver/release notes aligned). If it’s not intended as part of this PR, please revert that portion (or split it out).
  5. CI signal before merge: gh pr checks 1956 currently shows “no checks reported”. Please rebase/push or have a maintainer trigger workflows so the PR has a visible CI pass before merging.

IMPROVEMENTS

  1. Remove/avoid console.error in library code: packages/babel/src/workerPool.js logs on terminate failure (console.error('Error terminating worker:', err)) and triggers no-console. Consider swallowing termination errors, or surfacing them via rejected promises instead of logging.
  2. Defensive postMessage error handling: worker.postMessage(task.opts) can throw synchronously (e.g. DataCloneError) if something slips past the serializability guard. Wrapping this in try/catch and rejecting with a targeted “non-serializable babel options in parallel mode” error would make failures much clearer.
  3. Default worker count rationale: Not blocking, but it’d be good to either document why the default is 4 or consider a CPU-based default (with a cap).
  4. Resolve/close review threads: There are still unresolved threads (Node compat / worker-pool library discussion). Not strictly code-blocking, but cleaning those up will make merge readiness clearer.

Comment on lines +20 to +24
footer(chunkInfo) {
if (chunkInfo.name === 'index') {
return 'module.exports = Object.assign(exports.default, exports);';
}
return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this now? I don't see any change in the index.js that would affect the package's shape

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to refactor rollup.config.mjs to add the worker entrypoint. So this code is re-implementing this functionality from the shared config which was being used before:

footer: 'module.exports = Object.assign(exports.default, exports);',

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: have you tested the plugin works now correctly in both CJS and ESM modes?

"@babel/core": "^7.0.0",
"@types/babel__core": "^7.1.9",
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
"rollup": "^2.0.0||^3.0.0||^4.0.0"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shellscape this required a major bump of this package - we should ensure that the automation will be able to bump this properly. I think that's achieved using some special tags in commit messages

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah including BREAKING CHANGES in the commit body or changing the PR title to reflect a major version in the conventional commit spec.

* @default undefined;
*/
filter?: ReturnType<CreateFilter>;
filter?: (id: string, code: string) => Promise<boolean>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's revert this

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is based on #1954, which should ideally be merged first. Would you prefer that I drop the other one? (they both touch the same code, so it would require some unpicking to separate them)

let transformOptions = !overrides.config
let transformOptions = !overrides?.config
? config.options
: await overrides.config.call(ctx, config, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would technically be a breaking change, right? we should call it out

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry - the function already comes prebound now. So there is no change here really. Those overrides can't be handled here in the parallel mode, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those overrides can't be handled here in the parallel mode, right?

Correct, and an error will be emitted if parallel: true is set alongside these options

Comment on lines +36 to +39
transformOptions =
babelHelpers === BUNDLED
? addBabelPlugin(transformOptions, bundledHelpersPlugin)
: transformOptions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so this now relies on babelHelpers input to skip the addBabelPlugin call in the renderChunk's case, right?

@shellscape shellscape changed the title feat(babel): add parallel processing via worker threads feat(babel)!: add parallel processing via worker threads Feb 16, 2026
},
customOptions,
error: this.error.bind(this),
runPreflightCheck: !skipPreflightCheck,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, this conversion make the code slightly harder to follow. We should standardize on a single variable name for this

Comment on lines +203 to +208
return workerPool.runTask({
inputCode: code,
babelOptions: { ...babelOptions, filename },
runPreflightCheck: !skipPreflightCheck,
babelHelpers
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: I'm not sure what we actually get from this.error but maybe it's worth rechecking if we shouldn't "convert" the errors from this to this.error calls?

Comment on lines +152 to +154
if (parallel) {
const parallelAllowed =
isSerializable(babelOptions) && !overrides?.config && !overrides?.result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps it would be nice to move this validation to unpackInputPluginOptions? It would also be quite great if the thrown error message could mention the offending option.

}

return transformCode(code, babelOptions, overrides, customOptions, this);
return transformCode({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: why we don't attempt to parallelize this one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants